1️⃣ 什么是防抖和节流?

🇨🇳 中文(面试标准答案)

🇺🇸 English (interview version)

 

2️⃣ 经典面试追问:二者的区别是什么?

🇨🇳 中文口语版

👉 举例:

🇺🇸 English

 

fn.apply()是什么意思?

理解这一点至关重要,因为无论是防抖还是节流,都会用到它。

我原来的理解是:fn.apply(this, args) ,只是fn绑定了指定的this和args,并没有执行。

这个理解是错的,所以防抖和节流的代码老是无法理解清楚。


你的理解其实只对了一半!你注意到了它在绑定 this 和传递参数,但实际上,apply 不仅会绑定,还会立刻执行函数

在 JavaScript 中,真正「只绑定、不执行」的方法是 bind,而 applycall 是会立即调用的

1. apply 的真正行为:绑定 + 立即执行

我们可以把 fn.apply(this, args) 拆解为两个动作同时发生:

  1. 换身体(改变 this 指向):把函数内部的 this 指向你传进去的第一个参数。
  2. 按按钮(立即执行):把 args 数组拆开作为参数传进去,然后瞬间执行这个函数。

image-20260708084211590

2. 为什么在防抖/节流里要用 apply

如果你直接写 fn(...args) 也能执行,但为什么要大费周章地写 fn.apply(this, args) 呢?

这是为了完美冒充

防抖和节流函数是作为「代理人」包裹在原本的事件函数外面的。为了让原本的函数(fn)感觉自己就像是在原生事件里被直接触发的一样,必须保证它的 this 上下文和参数不丢失。

举个经典的例子:原生的事件绑定

假设我们在网页上监听一个按钮的点击:

在原生的点击事件中:

如果不用 apply 的惨剧:

如果我们直接在防抖内部调用 fn()

用了 apply 后的完美还原:

核心总结

 

 

防抖和节流,我卡住的地方

防抖和节流,整体函数是什么意思,看到定义就知道了。但是我卡住的地方就在于,我不理解里面的fn到底是怎么执行的。闭包 + 高级函数 + setTimeout,我的大脑就是转不动。

卡点1:timer到底是存在了哪里?为什么第二次点击时可以判断 if(timer) 呢?这个不是函数里面定义的变量吗?

你的直觉非常敏锐!你问到了 JavaScript 中最核心、最经典,也最让人头疼的一个概念——闭包(Closure)

你说的没错:“如果 timer 是函数内部定义的变量,函数执行完不就被销毁了吗?为什么第二次点击还能访问到上一次的 timer?”

我们用两张内存结构图来彻底看清 timer 到底藏在哪里。

1. 核心误区:你以为的执行过程(错误理解)

如果你把代码写成这样,确实会像你担忧的那样,每次点击 timer 都会被重置:

 

2. 揭秘真相:防抖真正的内存结构(闭包)

但在防抖的实现里,我们用了一个嵌套函数。

当你执行 const myClick = debounce(fn, 1000) 时,debounce 函数只执行了一次

看下面这张图,它展示了当你初始化防抖连续点击时,内存里到底发生了什么:

3. 用大白话总结 timer 存在哪里

所以,第二次及以后点击时,if(timer) 判断的不是一个新声明的变量,而是那个一直住在“温室”里、记录着上一次定时器状态的同一个 timer 变量

 

卡点2:fn到底是怎么执行的?

其实就是 setTimeout + 用户不断触发时 这个过程我不是很理解。


我完全理解了!你的纠结卡在了一个核心盲点上:“我手在点,代码在跑,定时器在转,到底谁在什么时候把真正的 fn 推出去执行了?”

我们把外层的包裹函数比作“传达室保安”,把真实的 fn 比作“办业务的客人”。我们再来看一次最细致的执行轨迹。

一、 防抖(Debounce)的内层执行细节

假设 delay2秒

我们在第 0 秒点第一次,第 1 秒点第二次,然后停手。

1. 第 0 秒:第一次点击
  1. 用户点击,触发了外层的“保安”函数。
  2. if (timer) clearTimeout(timer); 执行 —— 此时 timernull,啥也没清。
  3. timer = setTimeout(...) 执行。注意!此时内层的 fn.apply 只是被打包塞进了定时器,它并没有执行,它在闭合的闹钟里“沉睡”。 闹钟定在 2 秒后(即第 2 秒)。
2. 第 1 秒:第二次点击(关键点)
  1. 用户又点了一次,“保安”函数再次响应。
  2. if (timer) clearTimeout(timer); 启动!这个动作瞬间把第 0 秒定下的那个闹钟砸碎了。 原来在里面沉睡的 fn 直接被销毁,根本没机会执行。
  3. timer = setTimeout(...) 再次执行,重新打包一个 fn.apply 塞进一个新闹钟。这个新闹钟定在 2 秒后(即第 3 秒)。
3. 第 3 秒:静止满 2 秒
  1. 从第 1 秒到第 3 秒,用户没有再点击。
  2. 到了第 3 秒,新闹钟准时响起。
  3. 闹钟内部的包裹代码被激活,终于轮到 fn.apply(this, args) 执行了!
  4. fn 真正开始跑它自己的业务逻辑(比如发送搜索请求)。

二、 节流(Throttle)的内层执行细节

还是以时间戳版为例,假设 delay(CD时间)是 2秒

用户在第 0 秒点击,在第 1 秒拼命狂点,第 2 秒又点。

1. 第 0 秒:第一次点击
  1. 此时 lastTime = 0
  2. 用户点击,进入函数。获取当前时间 now = Date.now()
  3. 判断条件:now - lastTime (1784079026486- 0 = 1784079026486) delay (2000)。条件成立!(此时必然成立)。
  4. 立即执行 fn.apply(this, args)fn 在第 0 秒直接被调用。
  5. 执行完后,lastTime = now,也就是说 lastTime 变成了 1784079026486
2. 第 1 秒:用户狂点
  1. 无论用户点多少次,每次点击都会进来获取当前时间 now = 1784079026486
  2. 判断条件:now - lastTime (1784079027491 - 1784079026486 = 1005) delay (2000)。条件不成立!
  3. 代码直接跳过 if 块,直接结束。 内层的 fn.apply 连露脸的机会都没有,完全被无视了。
3. 第 2 秒:再次点击
  1. 用户在第 2 秒点击,进来获取当前时间 now = 1784079028497
  2. 判断条件:now - lastTime (1784079028497 - 1784079026486 = 2011) delay (2000)。条件刚好成立!
  3. 再次执行 fn.apply(this, args)fn 第二次被成功调用。
  4. 更新 lastTime = 1784079028497,下一轮 2 秒 重新开始。

终极理清思路

这么拆开来看每一秒发生的事情,有没有感觉逻辑线连上了?

重点理解:

看似用户多次触发会产生很多 fn 在“计划表”中(比如说 2000ms 之后把fn推入执行队列),好像很多 fn 都会依次执行,但其实只有真正的 fn 才会执行,其余的 fn 会随着 timer 的注销而注销。这一点真的是重点。

 

卡点3:clearTimeout(timer),我没有真正理解其含义

看上去只是清理一个延时函数,但其实里面做了很多事情,其中最重要的就是将延时函数里面的 fn 注销掉了。


clearTimeout(timer)连根拔起,直接把还没来得及执行的 fn 彻底注销掉。

为了让你完全放心,我们来看看浏览器底层到底是怎么管理这个过程的:

1. setTimeout 到底干了什么?

当你执行 timer = setTimeout(fn, 2000) 时,JS 引擎其实做了两件事:

  1. 它在浏览器的后台“计划表”里注册了一个任务:“在 2000 毫秒后,把 fn 推入执行队列。
  2. 它会返回一个整数 ID(比如 123),这个 ID 就是那张计划表里的行号(也就是你的 timer 变量拿到的值)。

2. clearTimeout(timer) 的注销动作

当你在第二次点击触发 clearTimeout(timer) 时,你其实是拿着这个行号对浏览器说:“把计划表里第 timer 行的任务给我擦掉!

此时,浏览器会立刻去后台:

3. 用一个最生活化的比喻

你可以把 timer = setTimeout(fn, 2000) 理解为“点外卖”:

当你连续点击,触发 clearTimeout(timer) 时,就像是你突然“取消了订单”:

所以,老的那次 fn 是被真正地、彻底地注销了,绝对不会在暗地里悄悄执行。

 

3️⃣ 手写防抖

JavaScript 实现

面试口语解释

🇨🇳: 核心就是用 setTimeout + clearTimeout,每次触发都清掉上一次的定时器,只保留最后一次。

🇺🇸: We use setTimeout and cancel previous timers with clearTimeout to ensure only the last call is executed.

 

4️⃣ 手写节流

时间戳版本(常见)

定时器版本(另一种)

面试解释

🇨🇳: 节流就是“规定时间内只允许执行一次”,要么用时间戳控制,要么用定时器锁住。

🇺🇸: Throttle ensures the function runs only once per time interval using either timestamps or a timer lock.

 

5️⃣ 进阶追问(面试官很爱问)

Q1:防抖能不能立即执行第一次?

👉 可以(leading debounce)

这里的 setTimeout 里面,是不是缺少这句代码fn.apply(this, args)

刚开始,timer为null,fn会立即执行第一次,只有等到delay时间之后触发,timer在delay时间之后被设置为null,解锁,让下一次调用可以再次立即执行。

立即执行第一次的debounce和之前例子的debounce执行是不同的:

举个按钮例子

代码:

用户:

时间:

最终:

只打印一次。

两者对比

Leading debounce(立即执行)

代码:


Trailing debounce(延迟执行)

这个就是我们之前实现的代码。

代码:

 

Q2:节流最后一次能不能执行?

可以(trailing throttle),但需要稍微升级实现(面试加分点)

 

Q3:应用场景区别(非常重要)

场景技术
输入搜索防抖
resize节流
scroll监听节流
按钮点击防重复提交防抖
游戏按键节流

 

6️⃣ 一句话总结(面试杀手锏)

🇨🇳 中文

🇺🇸 English

 

7️⃣ 如果面试官继续深挖(加分点)

可以这样说:

防抖适合“最终状态”,节流适合“过程控制”

 

 

防抖节流 + React Hooks 实现版(useDebounce / useThrottle)

下面实现的

主要用于“值(value)的防抖 / 节流”

而我们最开始讲的 JavaScript:

是用于函数(callback)的防抖 / 节流

 

但其实函数的防抖和节流,在项目中还是很常见的,所以这种hooks也是很常见的:useDebounceCallbackuseThrottleCallback

比如说 window resize 时,需要加上防抖。

实现也很简单,像js那样实现即可。

① useDebounce(React 防抖 Hook)

使用场景

核心思路

实现代码

面试解释(口语版)

🇨🇳: 每次 value 变化时我不会立即更新,而是启动一个定时器。如果在 delay 时间内 value 又变化了,就清除上一次的定时器,只保留最后一次更新。

🇺🇸: It delays updating the state until the value stops changing for a given delay.

使用方式

 

② useThrottle(React 节流 Hook)

使用场景

核心思路

版本1:时间戳实现(推荐)

版本2:定时器实现(更稳定)

面试解释(口语版)

🇨🇳: 节流就是控制更新频率,在一定时间内只允许执行一次 state 更新。

🇺🇸: Throttle limits how often state can be updated during continuous changes.

 

 

③ React 面试加分点(重点!!!)

面试官经常追问👇

Q1:为什么要用 useRef 存 timer?

👉 答:

🇨🇳: 因为 useRef 不会触发重新渲染,而且可以跨 render 保持同一个值。

🇺🇸: useRef keeps a stable mutable value across renders without causing re-renders.

 

Q2:useDebounce vs useThrottle 本质区别?

维度useDebounceuseThrottle
触发方式停止后执行按间隔执行
场景搜索滚动
控制方式清定时器限制频率

 

Q3:能不能封装成“函数防抖 Hook”?

👉 可以升级(高级面试题):

 

④ 面试终极总结

🇨🇳 中文

🇺🇸 English